B站压测实践之压测平台的演进
本期作者
徐光耀
bilibili资深测试开发工程师
负责B站工程效率相关平台的设计开发;压测、ci流水线、云构建等多个平台基于云原生方案的落地实践者。
01 背景
压测的重要性毋庸置疑,相比于监控,压测可以说是主动手段,通过高负载的预演,及时发现线上服务的瓶颈和缺陷,对线上服务质量保障起到了至关重要的作用。而在B站,核心业务都会频繁进行压测,主要有几种场景:
针对新应用,挖掘高负载下可能暴露出的功能缺陷,同时探测服务承载极限,合理规划实例数量。
针对老应用,在功能迭代过程中,通过持续压测,观察功能变动对性能的影响。
针对大型活动(S赛事、拜年纪、跨晚等等),按照预估峰值流量来进行全链路预演,发现性能瓶颈点,提前扩容弥补。
我们会分上下两篇来分别介绍B站压测平台的演进和全链路压测实践。本文主要介绍B站压测平台的演进。
02 B站压测演进之路
B站的压测演进之路经历了3个阶段:手工阶段 → 平台1.0阶段 → 平台2.0阶段。
手工阶段:2018年之前,压测完全由各个团队研发或测试各自写脚本并辅助开源压测工具(如Jmeter)完成。由于单节点难以实现高并发,通常需要实现多节点分布式压测能力,门槛较高,所以压测由各团队分治的缺点就很明显,人力成本++,资源成本++,而完全可以通过平台来提供通用的压测能力,既降低了压测的使用门槛,同时资源也能统筹利用。
平台1.0阶段:于是2018年推出了第一版压测平台,通过平台交互将用户的接口配置转化为Jmeter脚本,而底层压测引擎结合了云原生+Jmeter,每一个pod的容器执行一个Jmeter脚本,通过调度增减Pod数量来控制并发数。但是经过较长一段时间的平台运营之后,我们就发现这种设计存在较多瓶颈和问题:
问题一:Jmeter需要人为设定并发数,容易造成不合理配置,资源浪费或过载。
问题二:Jmeter不支持分布式文件传参且保证参数不重复。
问题三:Jmeter在传参文件过小的情况会出现磁盘IO瓶颈。
问题四:Jmeter场景以xml格式描述,场景模版事先定义,难以动态扩展,灵活性差。
平台2.0阶段:在2021年年初,我们推翻了压测1.0设计,推出了全新的压测平台2.0,解决了Jmeter带来的诸多瓶颈和限制,通过自研发压客服端以及基于监控的自适应调度算法,实现了智能发压引擎。
03 压测平台设计
接下来具体介绍压测平台2.0是如何设计实现的。首先明确我们的目标是提供自助压测平台,这里有两层含义:
其一,平台提供的功能操作要尽可能简化,降低平台使用门槛,用户可以自主完成压测的全部操作,无需平台维护人员介入;
其二,平台支持的场景要尽可能丰富,满足各种压测需求,尽量减少用户平台外的操作成本。
3.1 压测引擎实现
基于平台目标,理想的压测引擎是:用户只需要给出他期望压力值即可,压测引擎按照期望值去施压,用户无需关注分布式节点并发数分配的所有细节。
3.1.1 设计考量与反思
在设计底层引擎的时候,我们首要考虑的是如何尽可能提高施压上限,这里有两个关键点:
第一,发压客户端采用分布式部署,并且节点数量可扩展。
第二,充分利用分布式施压集群里每一个节点的资源。
针对第一点,我们结合云原生技术,给压测单独分配一个Paas资源池,根据资源总量,以每个Pod分配4C8G来换算Pod数量,每个Pod里起一个容器运行发压客户端作为一个节点。这样做有两个好处:
节点易管理:节点统一资源规格,基于历史经验,Limit设置为4C8G对于单节点来说是一个合理的资源配置,对于部分QPS需求较低的压测任务来说,可能1个节点都用不满,这个规格设置不会造成太多资源浪费。
节点易扩展:将来可通过扩容K8S资源池,增加资源总量,从而提升发压客户端节点(Pod)个数上限。
而第二点是我们着重要解决的问题。首先我们来看单节点并发数与QPS之间的关系曲线,如图1,假设服务端承载足够大的情况下,单实例发压客户端随并发数的增长,QPS趋势呈3个阶段:
图1:单实例并发和QPS关系
第一阶段,节点处于低负载状态,QPS随并发数呈线性增长,此时资源还没有被充分利用。
第二阶段,节点处于接近满负载状态,QPS增长趋势开始变缓,此时资源得到充分利用。
第三阶段,节点处于超负载状态,QPS随着并发数增长,出现缓慢下降;此时过多线程的竞争起到了副作用,不仅QPS出现了下降,而且客户端的响应耗时也远远大于服务端实际值。
显而易见,第二阶段是最优并发数配额,单节点发挥了自身全部的资源价值,资源利用率是最优的。
回顾平台1.0的设计,当时采用的Jmeter作为发压引擎,启动的时候需要用户来指定该节点的并发数,显然这对于用户来说是个难题,他没法事先知道最优的并发数是多少,这个数值与实际的压测场景紧密相关,不同的接口以及接口组成的场景,这个最优解都是不同的。理论上用户只能通过多次尝试去探测出这个值。事实上大部分用户都不会这么做,“保守”的用户会给每个节点配置少量并发数,这样就需要更多的实例才能满足他的压力需求,很可能导致节点数量不足,而实际上并非资源不足,只是使用不合理;而“激进”的用户会给每个节点配置大量并发数,每个节点都超负载运行,虽然压力需求得到满足,但是客户端的请求响应耗时会远远大于服务端的统计,造成用户的疑惑。
3.1.2 新框架诞生
平台2.0针对上述问题,对底层发压框架进行了全面改造升级,核心有两点:
第一,通过自研发压客户端取代Jmeter,除了基本的HTTP和GRPC接口请求功能外,额外实现了动态线程池功能,可在运行时动态增减并发数。
第二,通过基于实例监控的自适应调度算法,自动探测每个实例的最优并发数配置,自动增减实例,实现智能分布式发压引擎。
图2:压测平台框架
平台2.0整体框架如上图2所示:
发压客户端容器化部署,每个发压客户端只能同时执行一个任务。
——资源统一规格划分,每个任务分配到不同的容器,多个用户的并行任务相互不受影响。
发压客户端采用控制器模式,容器常驻。
——快速响应,节省了Pod调度的时间,可满足瞬时高并发的场景。
平台通过Redis传递任务元信息和控制信号,通过网盘传递任务的执行文件,发压客户端来获取。
——上层平台与底层引擎异步解耦。
3.1.3 调度原理
下图3为具体的调度原理示意图:
图3:调度原理示意图
影响客户端实例调度的有两个因素,一个是客户端自身状态变化,一个是用户行为。
发压客户端有3种状态:空闲状态、加减压状态、稳定状态。
当处于空闲状态:主循环控制器从Redis任务队列中竞争任务,如果成功,便进入加压状态,如图三第二步所示;
当处于加减压状态:主循环控制器先判断当前自身负载(从cadvisor接口获取监控数据),如果已达满负载阈值,则进入稳定状态,当前并发数保持不变,同时将任务塞回Redis任务队列,如图三第三步所示,然后其他空闲实例会竞争该任务,如图三第四步所示;如果没有满负载则再判断当前是否超过期望QPS,如果是则通过减并发数来降压,特别地,若并发数降为0,则设置Redis控制信息标记该任务需要继续减压,同时该实例退出任务变成空闲状态,如果没有超过期望QPS,则按照加压模式执行加压策略;
当处于稳定状态:主循环控制器获取Redis控制信号,判断是否要终止或减压,如果需要终止,则清理退出任务,如果需要减压,则状态变成加减压状态;若无上述控制信号,则维持不动;
用户有两种操作行为会影响容器调度,第一个是启动任务,如图3第一步所示,平台会往Redis队列塞任务;第二个是加速,如图三第五步所示,此处加速是指当单实例加压速率(上限为单次增加30并发)无法满足需求时,平台会通过增加并行实例数量的方式,提高加压速率。
3.1.4 发压模式
平台2.0在上层暴露给用户2种加压模式选项:指定QPS模式和指定并发数模式。
两种模式相同的是,在压测前,都需要指定本次压测最大期望QPS,用于流程审核,风险把控;在压测过程中,一旦当前QPS超出最大期望QPS,便强制通过减小并发数来降低QPS,直到低于最大期望,此为发压客户端里主循环控制器的首要判断条件。
两种模式不同的是,指定QPS模式在压测过程中,用户可调整“当前期望QPS”以及“加压速率”,实现精准的QPS数值控制以及QPS变化的速度(本质上发压引擎还是通过并发数、时间间隔的调整,转化为QPS的变化,只不过这一步交给底层逻辑实现,对用户透明);指定并发数模式在压测过程中,用户可调整“当前期望并发数”,实现自由的并发数控制。
实际上,80%以上的压测场景更适合采用指定QPS模式,用户主要关心QPS指标,这种模式下用户一次压测过程中可以逐步加压,例如他的预期最大QPS是10w,但能不能达到10w,或者甚至超出10w,心里也没底,这时就可以采用这种模式,从初始1w开始,分阶梯加压,比如每次增加2w,那么依次调整预期QPS值为1w、3w、5w、7w、9w,甚至更大(上限为最大预期QPS),每次达到阶梯预期目标时可稍作停留,观察客户端的响应耗时和报错数量的变化以及服务端的相关监控数据。通过这种方式,可以轻松探测出服务的承压极限。那么什么场景下更适合采用指定并发数模式呢?如果压测场景对瞬时并发数量有要求,可以采用这种模式,例如电商的抢购场景,此时可设置较大的初始并发数,来模拟大量用户瞬时抢购的场景。
最后总结一下框架设计,底层通过自研发压客户端和基于负载监控的自适应调度算法实现了智能发压引擎,而上层平台完全屏蔽了底层复杂逻辑,用户只需要选择合适的加压模式,并指定期望QPS、加压速率或期望并发数等参数即可。
3.2 压测场景模式
用户的压测场景是多样化的,大致可归纳为以下几类:
需求一:单接口请求内容支持不同传参值进行压测,例如HTTP的Header,GET请求的param,POST请求的body,GRPC的metadata等等
需求二:在需求一的基础上进行多接口组合,支持并行或串行接口组合方式进行压测
需求三:线上真实流量录制保存,在对其预处理之后进行回放
平台1.0基于Jmeter的预设场景模版,在一定程度上满足了需求一和需求二,但缺乏灵活性。而平台2.0基于上述需求,重新设计,抽象成2类场景模式,分别称为自定义场景和流量录制回放场景。
3.2.1 自定义场景
自定义场景,顾名思义,用户可灵活定制压测场景,满足多样性需求。
接口内容的多样性
a. 传参
压测接口往往需要传递不同的参数值,满足多样化场景需求。为此我们支持3种接口内的传参方式:
文件传参:每行一组参数值
随机函数:随机日期 / 随机数 / 随机字符
签名函数:Md5签名算法 / B站内置签名算法
例如有一个HTTP的GET接口A:http://api.bilibili.com/test?name=${name}&type=${type}&value=${__RANDOMNUM,1,12},其中有3个参数name、type和value需要动态传值:
name和type采用文件传值,在接口中用${name}占位name值、${type}占位type值,然后上传一个文件,约定一个分割符,文件的每一行内容用该分割符切分后的第一项传给name,第二项传给type
value采用随机函数传值,{}内第一项表示函数名,后面几项是该函数传参,以${__RANDOMNUM,1,12}为例,表示取[1, 12)之间的随机整数
文件传参是在平台后台生成执行文件的过程中进行值替换的,而随机函数和签名函数是在发压客户端实际执行压测的过程中替换的。
b. 转发
接口支持3种转发方式可选:外网模式、内网slb模式以及内网服务发现直连模式,可根据需求自行选配。如果想压测完整链路,可配外网模式;如果想针对部分实例压测,可选内网服务发现直连模式,选择部分在线的节点或集群。
c. 断言
接口响应返回内容支持断言,断言方式支持等于、不等于、包含、不包含等多种判断条件。针对http协议接口,先断言http状态码是否在[200, 300)之间,如果是再断言返回内容,否则即认为断言失败;针对grpc协议接口,判断有无报错异常,如果正常返回,再断言返回内容。
接口之间组合的多样性
支持接口串并行组合,多个接口串行组成子任务,多个子任务并行组成完整压测任务,如图四所示。串行接口之间支持将上游接口响应内容作为下游接口输入参数值,首先上游接口先定义其响应内容(必须是Json格式)的哪个字段值作为传参值,并定义其参数名,而在下游接口中用该参数名占位即可,方式类似文件传参。在实际压测执行过程中施压客户端会提取上游接口的响应内容并识别下游接口对应的占位符并进行替换,从而实现上下游传参。
图4:接口组合示意图
用户在平台上配置完自定义场景之后,后台会根据场景的接口配置和传参文件内容自动拼接成执行文件,每一个子任务对应一个执行文件,执行文件的每一行对应一组串行接口。提前拼接所有传参文件,主要基于以下几点考虑:
拼接逻辑前置到平台完成,降低发压客户端的处理复杂度,保留更多的资源用于发压,从而提升客户端的压力上限
对小文件进行预处理,解决文件频繁读造成的磁盘IO瓶颈
通过文件切割形成文件队列,从而支持分布式文件内容不重复
文件内容按重复和不重分成两种模式,如图5所示:
文件重复模式
串行子任务中,如果有多个接口有文件传参,生成执行文件时按最大传参文件行数进行遍历,每个接口拼接各自传参文件的一行参数值,再合并成一个list,在执行文件末尾插入一行,最后得到一个执行文件;后续发压客户端通过网盘共享这一个文件,按文件的每一行任务信息进行施压。
特别地,约定如果最大文件行数小于阈值D,则按阈值D进行次数遍历,避免传参文件行数过小,由于传参是可重复的,所以多遍历几次也没有实质影响。
文件不重模式
串行子任务中,如果有多个接口有文件传参,生成执行文件时按最小传参文件行数进行遍历,每个接口拼接各自传参文件的一行参数值,再合并成一个list,在执行文件分块的末尾插入一行,当分块行数达到阈值k时,切换到新的分块继续插入,最后生成多个执行文件分块,以分块名组成队列;后续发压客户端通过消费分块队列,获得分块名后再去网盘上读取相应的分块文件,按文件的每一行任务信息进行施压;当分块队列被消费完,且所有实例获得的分块文件都执行完压测时,任务自动停止。
图5:不同模式下文件预处理逻辑
3.2.2 流量录制回放场景
除了设计自定义压测场景,还有一种诉求是通过录制线上真实流量,并进行回放进行压测。这种方式有2个优点,其一线上真实流量对接口的各种参数覆盖更加全面,其二录制省去了人工构造的成本。
在业界像Java技术栈已有比较成熟的流量录制方案,采用jvm-sandbox-repeater实现。在B站大部分微服务应用都采用Golang技术栈,少部分采用Java技术栈,所以需要一套跨语言通用的流量录制方案。有人提出基于sdk的方式,业务代码集成流量录制的sdk来实现,这种方案存在以下几个问题:
每一种语言都需要开发一套sdk,不通用。
每一种语言的sdk需要分别支持不同通信协议(http和grpc),以及区分入口请求和出口请求。
侵入代码,所有微服务需集成sdk,造成与业务逻辑耦合,一旦sdk出问题,将影响所有微服务,而且sdk版本的迭代,需要所有微服务配合代码更新。
经过调研和尝试,最终我们提出一种跨语言通用的非代码侵入式的微服务流量录制方法,具体可以实现以下效果:
基于单独的伴生服务,无需考虑业务方语言,跨语言通用;
伴生服务的录制逻辑同时兼容http和grpc协议;
无需侵入业务代码;
独立的容器资源,即使出现异常,也不会影响主容器服务的正常运行。
图6:流量录制回放框架
其架构如图6所示,分成三个部分:
流量监听伴生服务
利用同一个pod内所有容器共享网络栈的原理,基于伴生容器,通过旁路方式监听微服务的入口端口以及所有出口端口,从而获得所有出入口流量,并且做到主服务无感知,不受影响。
主进程开启定时轮询,每60秒请求一次平台heartbeat接口,请求内容带上应用和实例的相关信息(包括当前开关状态),一来在平台上注册该应用实例的存活状态,记录最新存活心跳(时刻),以及当前开关状态,二来heartbeat接口返回内容包含了平台上用户对该应用的相关配置信息,包括接口录制规则、流量采用率等等,还有预期开关状态,决定当前开启还是关闭流量录制。
如果开启录制,则根据不同的服务类型和出入端口,分别执行开源工具tshark相应的网卡监听命令;特别地,对于grpc服务,tshark启动时需要指定该服务定义的proto文件,平台返回的相关配置信息中包含grpc服务的proto文件所在git仓库信息,所以在启动tshark之前通过git接口获取所需proto文件,并置于tshark所要求的路径下;将tshark监听网卡录制到的指定出入端口的流量进行格式化处理,tshark录制到的流量是按请求和响应拆分的,每一条流量信息要么是一次接口请求,要么是一次接口响应,将监听到的请求先缓存在内存中,等其对应的响应到来时,拼接成完整的一次接口信息(包含请求和响应);将上述拼接完的接口信息,对其中部分可能存在敏感信息的字段进行加密处理,然后整体序列化后上传到Kafka,进行收集。
流量收集系统
采用业界通用流式数据处理方案。按如下步骤:
a. 首先Kafka集群负责接收从各个微服务实例的伴生容器发送过来的格式化后的流量数据;
b. 由Logstash服务负责从Kafka队列中去消费流量数据,并按写入ElasticSearch数据引擎;
c. ElasticSearch按微服务应用名分索引存储各应用的流量数据,供平台查询进行展示。
流量录制管理平台
用户在平台上注册应用并配置相关信息,伴生容器定时请求平台,将当前伴生容器的心跳发生给平台,表明当前存活状态,同时平台将当前应用的用户配置信息返回给伴生容器,包括接口录制规则、采样率、预期开关状态等信息,通过这种方式灵活控制开启录制的实例个数、流量录制的采样率以及接口录制规则。分为三个部分:
第一部分是心跳收集/配置同步,主要负责与各微服务的伴生进行交互,平台暴露一个heartbeat接口,供伴生服务调用,平台收到心跳请求时,按如下步骤执行:
a. 根据传参信息(应用、实例、开关状态),在数据库中更新该应用该实例的心跳时刻和当前开关状态,如果原先数据库中不存在该应用该实例的心跳记录,则新插入一条记录;
b. 查询该应用的相关配置信息,包括流量录制规则、流量采用率、当前实例预期开关状态,作为请求的响应返回给伴生服务;
此外,平台后台定时执行配置同步(10秒一次),查询应用预期开启录制的实例个数,查询该应用实例的心跳记录,筛选出当前存活的实例,统计当前开启监听的实例个数,与预期启动监听的实例个数进行比较:如果当前开启的个数n少于预期m,则从当前存活且预期开关状态为关的实例中随机选取(m-n)个实例,将数据库中其预期开关状态变更为开;如果当前开启的个数n大于预期m,则从当前存活且预期状态为开的实例中随机选取(n-m)个实例,将数据库中其预期开关状态变更为关;如果当前开启的个数等于预期,则维持不动。
场景举例:微服务X有10个实例,设置3个实例开启流量录制。当X进行版本升级时,旧版本A下线,新版本B上线,此时旧版本A的10个实例的容器都会收到终止信号,执行销毁操作,这样旧版本A的10个实例的心跳就停留在当前时刻,而新版本B的10个实例启动之后就开始发心跳,而其当前状态以及默认初始预期开关状态都是关,此时平台定时执行配置同步的时候,就会发现微服务X的预期开启录制的实例个数是3,而实际存活的且开启录制的实例个数是0,则就会在当前存活的版本B的10个实例中随机挑选3个实例,将其数据库中预期开关状态更新为开,这样后续这3个实例的伴生服务发来心跳时,就会获得最新的预期开关状态为开,就会开启录制。
第二部分是应用接入/规则配置,用户如果有新应用想使用流量录制功能,执行如下步骤:
a. 在平台上新增应用,完成应用相关的信息配置,例如如果该应用提供grpc服务,需要配置grpc服务定义的proto文件所在的仓库及其路径;
b. 在平台上配置该应用的录制规则,包括:接口path(支持正则、黑白名单模式)、开启录制的实例个数、流量采样率(基于单个实例);
第三部分是流量查询展示,用户在平台上可查看某个应用下录制到流量,可根据时间、path等条件进行流量过滤。平台从ElasticSearch获取数据时,对相关加密字段进行解密处理,然后传给前端进行展示。另外,只有该应用有权限的人员才能访问对应的数据。
上述就是流量录制的方案。当获得真实流量之后,就可以通过筛选过滤出想要进行回放的接口,并导出成执行文件,这里的执行文件的格式与3.2.1中保持一致,这样压测引擎可以直接适配,进行流量回放压测。
04 全链路数据隔离
压测过程中,既有读接口也有写接口,为了保证线上数据安全不受污染,需要做到数据隔离。主要有两种方案:
账号隔离
在线上生成一批测试账号,专门用于压测,后续压测过程中所有写接口都绑定测试账号,这样就不会影响真实用户的数据,同时能实现全链路的需求。
优点:无需额外开发工作。
缺点:如果测试场景对账号属性有要求,则需要自行构造满足条件的账号,真实性可能有所欠缺。
影子库隔离
在压测请求的header/metadata中带上特定的压测标记,后台服务集成的框架中增加标记识别,如果请求带上了压测标记,则所有中间件、缓存、数据库都走影子库。
优点:真实用户数据压测,效果更好。
缺点:框架改造工作量大;影子库管理复杂。
80%以上的压测需求采用账号隔离的方式即可满足,剩下一些场景需要采用流量录制回放模式配合影子库隔离来进行全链路压测。影子库隔离的具体实现比较复杂,后续会其他同事单独写一篇文章来介绍,敬请期待。
05 压测实践效果
当前B站95%以上的业务都会走压测平台进行压测,尤其在大型活动准备期间,压测次数就会陡增。使用平台进行压测的次数趋势如图7所示,可以看到在21年下半年出现了明显的上升,这期间正是经历了S11赛事、电商大促、跨晚等大型活动,平台2.0在21年年初上线,提供了丰富、完整、稳定的压测能力,很好地支持了这些活动多样化的压测需求。
图7:压测使用次数趋势
以S11赛事为例,总共进行了3轮集中式的压测预演,累计进行了1000+次压测,涉及130+个微服务。在压测场景设计方面,自定义场景模式满足了直播间场景的压测需求,支持十多组接口同时并发,并且在压测过程中可独立控制各组接口的压力。除此之外,电商团队会使用预期并发数模式进行压测,模拟瞬时高并发的场景,演练会员购大促抢购活动;部分业务线已开始使用流量录制回放模式,进行日常压测回归。
06 后续规划
当前压测平台记录并实时展示了客户端的性能指标趋势图,包括QPS、平均响应耗时、错误数、吞吐量、并发数、不同分位响应耗时等,而用户如果要观察服务端的监控数据,则需要去另外的监控平台查看。后续规划与线上监控这块进行有机结合:
第一,压测平台与线上监控平台打通,客户端与服务端的监控数据在平台上同屏展示,便于用户在压测过程中实时观察以及定位问题。
第二,当前压测执行熔断是以客户端的接口响应错误率作为判定指标,后续考虑将服务端的性能指标纳入熔断判定机制,例如用户可配置规则/阈值:当服务端负载cpu使用率超过70%,则自动熔断,停止压测。